ES Module vs CommonJS
面试准备中关于模块化的知识点整理,主要是 ES Module 与 CommonJS 的对比。
语法差异
在 CommonJS 中,每个模块内部有一个 module 对象,其中 module.exports 是真正用于导出的对象。而 exports 只是 module.exports 的一个 初始引用(别名)
导出(Export)
// CommonJS
module.exports = { foo, bar }; //整体替换module.exports
// 或
exports.foo = foo; 本质上等于: module.exports.foo = foo;
// ES Module
export { foo, bar };
export default foo;
// moduleA.js
exports.foo = 1;
exports.bar = 2;
// 导出结果: { foo: 1, bar: 2 }
// moduleB.js
exports.foo = 1;
module.exports = { bar: 2 };
// 导出结果: { bar: 2 } ← foo 丢失了!因为 module.exports 被整体替换导入(Import)
// CommonJS — 运行时同步加载
const { foo, bar } = require('./module');
const myModule = require('./module');
// ES Module — 静态分析,编译时加载
import { foo, bar } from './module.js';
import myModule from './module.js';
import fs from 'fs'; // 命名导入
import('dynamic-module'); // 动态导入(异步)"运行时同步加载" vs "静态分析,编译时加载"
1. CommonJS — 运行时同步加载
"运行时"指的是代码真正被执行的时候,"同步"指的是 require() 会阻塞后续代码执行,直到模块加载完毕。
// a.js
console.log('a.js 开始执行');
const b = require('./b'); // ← 到这里,暂停 a.js,去执行 b.js
// b.js 全部执行完后,拿到导出值,再继续
console.log('a.js 继续执行');
// -----
// b.js
console.log('b.js 开始执行');
module.exports = { name: 'moduleB' };
console.log('b.js 执行完毕');执行 node a.js,输出顺序:
a.js 开始执行
b.js 开始执行
b.js 执行完毕
a.js 继续执行关键点:require() 本身就是一个普通的函数调用,它在代码运行到那一行时才去读取文件、解析、执行模块。这就是"运行时加载"。
而且 require() 是一个普通函数,所以它可以放在任何地方:
// 可以放在 if 里
if (process.env.NODE_ENV === 'production') {
const logger = require('./logger.prod');
}
// 可以放在函数里
function init() {
const db = require('./database');
}
// 可以放在循环里(虽然不推荐)
for (let i = 0; i < 3; i++) {
const mod = require('./module');
}因为这一切都发生在运行时,JavaScript 引擎只需要从上到下执行代码就能搞定。
2. ES Module — 静态分析,编译时加载
"静态分析"指的是不运行代码,仅通过阅读代码结构(语法层面)就能分析出模块的依赖关系。"编译时"指的是这些分析发生在代码真正执行之前。
ES Module 的执行分为三个阶段:
阶段1: 构建(Build)—— 找到所有 import/export,确定模块依赖图
阶段2: 实例化(Link)—— 创建模块实例,建立 import 和 export 的绑定关系
阶段3: 执行(Execute)—— 逐个执行模块代码,填充绑定的值用代码来说明:
// main.js
import { add } from './math.js';
import { name } from './utils.js';
console.log(add(1, 2));
console.log(name);// math.js
export function add(a, b) { return a + b; }// utils.js
export const name = 'hello';浏览器/Node.js 处理过程:
阶段1(构建):
扫描 main.js → 发现 import { add } from './math.js'
→ 发现 import { name } from './utils.js'
扫描 math.js → 发现 export function add
扫描 utils.js → 发现 export const name
→ 得到完整的依赖图(还没执行任何代码!)
阶段2(实例化):
为每个模块创建模块记录(Module Record)
在内存中分配空间,把 import 和 export "连接"起来
→ 此时 add 和 name 都是 undefined(还没执行)
阶段3(执行):
执行 math.js → add 函数被定义
执行 utils.js → name = 'hello'
执行 main.js → 调用 add(1, 2),打印结果这就是"编译时加载"的含义:依赖关系在代码执行之前就已经确定了。
3. 为什么"静态分析"很重要?
正是因为 ES Module 的依赖在编译时就已确定,才能实现以下关键特性:
① Tree Shaking(摇树优化)
// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// main.js
import { add } from './utils.js';
console.log(add(1, 2));打包工具(如 Webpack、Rollup)可以静态分析到 subtract 没有被导入使用,于是将其安全删除,减小包体积。
CommonJS 做不到这一点,因为 require() 是运行时的函数调用:
// 打包工具无法确定 require('./utils') 会用到什么
const utils = require('./utils');
utils.add(1, 2); // 也许还调用了 utils.subtract?静态分析无法确定② 编译时报错
// ❌ ES Module — 编译时就能发现错误
import { nonExistent } from './module.js'; // SyntaxError: 模块没有这个导出
// CommonJS — 运行时才能发现问题
const { nonExistent } = require('./module'); // nonExistent = undefined(静默失败)③ 不能用条件语句包裹 import
// ❌ 报错!因为编译阶段就需要确定所有依赖
if (condition) {
import { foo } from './module.js'; // SyntaxError!
}
// ✅ CommonJS 可以,因为 require 是运行时的普通函数
if (condition) {
const { foo } = require('./module'); // 完全合法
}4. 一句话总结
| CommonJS | ES Module | |
|---|---|---|
| 类比 | 像即时翻译:读到 require() 那一行才去加载模块 | 像提前做好翻译:先把所有 import 看完,整理好依赖关系,再开始执行 |
| 分析时机 | 运行时(执行代码时) | 编译时(执行代码前) |
| 加载方式 | 同步阻塞(一个一个来) | 提前构建依赖图,按依赖顺序执行 |
核心机制差异
3.1 加载方式
| CommonJS | ES Module | |
|---|---|---|
| 加载时机 | 运行时(Runtime)加载 | 编译时(Parse/Link 阶段)静态分析 |
| 加载方式 | 同步加载(require 是同步的) | 异步加载(浏览器中)/ 同步(Node.js 内置模块) |
| 条件判断 | 可以在 if 语句中使用 require | import 必须在顶层,不能放在条件语句中 |
// CommonJS — 可以在条件中 require
if (process.env.NODE_ENV === 'production') {
const logger = require('./logger.prod');
} else {
const logger = require('./logger.dev');
}
// ES Module — import 必须在顶层
// ❌ 以下写法不合法
if (someCondition) {
import { foo } from './module.js';
}
// ✅ 替代方案:动态 import()
if (someCondition) {
const { foo } = await import('./module.js');
}3.2 值的传递
这是两者最关键的区别之一:
// ========== CommonJS:值的拷贝(Copy) ==========
// counter.js
let count = 0;
module.exports = { count, increment: () => count++ };
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 ← 还是0!因为拷贝了原始值// ========== ES Module:值的引用(Live Binding) ==========
// counter.js
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← 值变了!因为是活绑定关键总结:CommonJS 导出的是值的拷贝,导入后模块内部的变化不会反映到外部;ES Module 导出的是值的活引用(Live Binding),模块内部修改导出值后,外部能实时感知。
CommonJS 中让外部感知模块内变化
可以实现,但需要一些技巧。 CommonJS 本身是值拷贝,没有活绑定,但有几种变通方案:
方案一:用函数/闭包代理(最常用)
既然函数是引用类型,通过闭包可以访问内部变量,那就用函数来充当"中介":
// counter.js
let count = 0;
module.exports = {
getCount: () => count,
increment: () => count++,
decrement: () => count--
};// main.js
const counter = require('./counter');
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2 ✅ 拿到了最新值
counter.increment();
console.log(counter.getCount()); // 3 ✅每次调用 getCount() 时,函数内部通过闭包访问的是模块内部的 count,所以永远是最新值。
缺点:每次读取都要调用函数,不能直接用 .count 属性。
方案二:用对象包装基本类型
把基本类型包装成对象,因为对象的属性修改是对同一个引用的修改:
// counter.js
const state = { count: 0 };
module.exports = {
state,
increment: () => state.count++,
};// main.js
const counter = require('./counter');
console.log(counter.state.count); // 0
counter.increment();
console.log(counter.state.count); // 1 ✅ 感知到了变化
counter.increment();
console.log(counter.state.count); // 2 ✅原理:state 是对象,require 拿到的是同一个对象引用。修改 state.count 等于修改同一个对象的属性,所有引用者都能看到变化。
缺点:需要额外包一层对象,代码不够直观。
总结
CommonJS 可以实现让外部感知变化,核心思路就是不要导出基本类型的值,而是导出能间接访问内部状态的函数或对象引用。但如果项目允许,直接用 ES Module 是最简单、最自然的选择。
3.3 this 的指向
// CommonJS — 顶层 this 是 module.exports
console.log(this === module.exports); // true
// ES Module — 顶层 this 是 undefined
console.log(this); // undefined4. 模块缓存与循环引用
4.1 缓存机制
两者都有缓存机制,require / import 同一模块不会重复执行:
// CommonJS — 缓存的是 module.exports 的值的拷贝
const a = require('./module');
const b = require('./module');
console.log(a === b); // true(引用同一个缓存对象)4.2 循环引用
// CommonJS — 循环引用时,只能拿到已经执行完毕的部分
// a.js
exports.done = false;
const b = require('./b');
console.log('a.js 中 b.done =', b.done); // false
exports.done = true;
console.log('a.js 完成');
// b.js
exports.done = false;
const a = require('./a');
console.log('b.js 中 a.done =', a.done); // false (只拿到 a.js 已导出的部分)
exports.done = true;
console.log('b.js 完成');// ES Module — 通过 Live Binding,循环引用可以拿到最终值
// a.js
import { bar } from './b.js';
export const foo = bar; // 此时 bar 还是初始值
setTimeout(() => console.log('a.js 中 bar =', bar)); // 最终值
// b.js
export let bar = 'initial';
import { foo } from './a.js';
console.log('b.js 中 foo =', foo); // undefined(TDZ 或初始值)
bar = 'updated';
a.js 开始 → 遇到 import 要去 b.js → b.js 执行:
bar 赋值为 initial → 要导入 a.js 的 foo,但 a.js 还没给 foo 赋值 → foo 是 undefined
→ 打印 undefined → 把 bar 改成 updated → b.js 结束
→ 回到 a.js → bar 已经是 updated 了 → foo = updated → 结束
→ 定时器触发 → bar 还是 updated → 打印 updated5. Node.js 中的兼容性
.mjs 和 .cjs 就是 强制声明模块类型的后缀 。 .mjs = 无论怎样都当 ESM, .cjs = 无论怎样都当 CJS。它们存在的意义是: 让你在同一个项目里可以同时使用两种模块系统,而不会产生歧义 。
Node.js 同时支持两种模块系统,通过文件扩展名和 package.json** 中的 **type** 字段**来区分:
// package.json
{
"type": "module" // 使用 ES Module
// 或 "commonjs"(默认值)
}| 文件扩展名 | type 未设置或 "commonjs" | type: "module" |
|---|---|---|
.js | CommonJS | ES Module |
.mjs | ES Module | ES Module |
.cjs | CommonJS | CommonJS |
文件扩展名的选择
你想写 CommonJS?
├── 项目默认是 CommonJS? → 用 .js 或 .cjs 都行
└── 项目默认是 ESM? → 必须用 .cjs(否则 .js 会被当 ESM)
你想写 ES Module?
├── 项目默认是 ESM? → 用 .js 或 .mjs 都行
└── 项目默认是 CommonJS? → 必须用 .mjs(否则 .js 会被当 CJS)
CJS 中使用 ESM 特性
// CommonJS 中可以使用动态 import()(返回 Promise)
async function load() {
const { default: lodash } = await import('lodash-es');
}ESM 中使用 CJS 模块
// ES Module 中可以用 default import 导入 CJS 模块
import lodash from 'lodash'; // ✅
// 但不支持命名导入(除非 Node.js 能静态分析)
import { debounce } from 'lodash'; // ❌ 可能失败6. 浏览器支持
| CommonJS | ES Module | |
|---|---|---|
| 浏览器原生支持 | ❌ 不支持 | ✅ 原生支持 |
| 使用方式 | 需要打包工具(Webpack、Rollup 等)转换 | <script type="module"> |
| HTTP 请求 | 不适用 | 每个 import 发起独立 HTTP 请求 |
<!-- ES Module 在浏览器中 -->
<script type="module">
import { foo } from './module.js';
console.log(foo);
</script>
<!-- CommonJS 需要打包后才能在浏览器运行 -->
<script src="bundle.js"></script>7. 顶级特性对比总结
| 特性 | CommonJS | ES Module |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载方式 | 运行时、同步 | 编译时、静态分析 |
| 值的传递 | 值的拷贝(Copy) | 活绑定(Live Binding) |
this** 值** | module.exports | undefined |
| 条件导入 | ✅ 支持 | ❌ 不支持(需用 import()) |
| 懒加载 | 天然支持(require 按需加载) | 需用动态 import() |
| Tree Shaking | ❌ 不支持 | ✅ 支持(静态分析可消除死代码) |
| 浏览器原生 | ❌ | ✅ |
| 异步加载 | ❌(同步阻塞) | ✅(天然异步) |
| JSON 导入 | require('./data.json') | 需 assert { type: 'json' } 或 fetch |
| 循环引用 | 可能拿到不完整的导出 | Live Binding 确保拿到最终值 |
8. 何时选择哪种?
CommonJS:Node.js 服务端项目、需要兼容旧版 Node.js、需要同步
require和条件加载的场景ES Module:现代前端项目(配合打包工具享受 Tree Shaking)、浏览器原生模块加载、新 Node.js 项目(官方推荐方向)
混合使用:ESM 作为主模块格式,通过
import()动态加载 CJS 模块
趋势:ES Module 是 JavaScript 模块化的未来标准方向,Node.js 官方也推荐新项目使用 ESM。但 CommonJS 在生态系统中仍然广泛存在,短期内两者会持续共存。